今天進入前端視覺化與家屬關懷儀表板開發階段。在前 14 天的實作中,我們已經建立了完整且強大的後端微服務與 SQLite 資料庫(含藥單明細與服藥打卡紀錄)。今天我們將使用 HTML5、Tailwind CSS 與 Chart.js,在 Flask 中打造輕量且質感極高的 Web 關懷儀表板。完成項目包含:
/dashboard)與靜態範本 — 在 Flask 中整合 render_template,渲染獨立的 Web 管理介面。/prescriptions 與 /intake-logs) — 透過前端 JavaScript 自動抓取歷史用藥清單、警語與打卡紀錄。雖然 LINE Bot 提供了即時推播與便捷的打卡體驗,但對於需要長期關懷長輩用藥狀況的家屬或社區照護員來說,對話視窗中的訊息容易被洗版。
擁有一個網頁版的 Web 關懷儀表板有以下核心優勢:
app.py)為了讓 Web 儀表板能抓取到長輩在 LINE 上打卡的歷史紀錄並渲染 HTML 網頁,我們需要在 app.py 中新增匯入項目與 API 路由。
請開啟 app.py:
1.讀者若沒有引入 render_template 與 Limiter,執行時會跳出 NameError。請將該區塊更新為:
# 請確認 app.py 頂部包含正確的 Flask 與 Limiter 匯入項目
from flask import Flask, request, jsonify, send_from_directory, render_template
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
2.新增路由處理邏輯:
@app.route('/intake-logs', methods=['GET'])
def get_intake_logs():
"""提供 Web 儀表板抓取長輩歷史服藥打卡紀錄 API"""
try:
conn = sqlite3.connect(DATABASE_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM intake_logs ORDER BY taken_at DESC LIMIT 50")
logs = [dict(row) for row in cursor.fetchall()]
conn.close()
return jsonify({
"status": "success",
"data": logs
}), 200
except Exception as e:
logger.error(f"查詢服藥紀錄失敗: {str(e)}")
return jsonify({"error": f"查詢失敗: {str(e)}"}), 500
@app.route('/dashboard', methods=['GET'])
def dashboard():
"""Web 關懷儀表板頁面路由"""
return render_template('dashboard.html')
templates/dashboard.html)在專案根目錄下新建 templates 資料夾,並於其中建立 dashboard.html 檔案:
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PrescriptionVLM - 高齡用藥遠端關懷儀表板</title>
<!-- 引入 Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- 引入 Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body class="bg-slate-50 text-slate-800 min-h-screen">
<!-- 頂部導覽列 -->
<header class="bg-indigo-600 text-white shadow-lg py-4 px-6 mb-8">
<div class="max-w-7xl mx-auto flex justify-between items-center">
<h1 class="text-2xl font-bold flex items-center gap-2">
<span>💊</span> PrescriptionVLM 關懷儀表板
</h1>
<span class="bg-indigo-500 text-xs px-3 py-1 rounded-full">系統運作中 (Online)</span>
</div>
</header>
<!-- 主要內容區 -->
<main class="max-w-7xl mx-auto px-4 grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
<!-- 左側:歷史藥單與語音播放 (佔 2 欄) -->
<section class="lg:col-span-2 space-y-6">
<h2 class="text-xl font-bold text-slate-700 flex items-center gap-2">
📋 最新解析藥單紀錄
</h2>
<div id="prescription-list" class="space-y-4">
<p class="text-slate-400">正在載入用藥紀錄...</p>
</div>
</section>
<!-- 右側:服藥打卡數據與圖表 (佔 1 欄) -->
<section class="space-y-6">
<h2 class="text-xl font-bold text-slate-700 flex items-center gap-2">
📊 服藥狀態與順從度
</h2>
<!-- 服藥打卡近況卡片 -->
<div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<h3 class="font-semibold text-slate-600 mb-4">近期 LINE 打卡動態</h3>
<div id="intake-log-list" class="space-y-3 max-h-60 overflow-y-auto pr-2 text-sm">
<p class="text-slate-400">載入打卡紀錄中...</p>
</div>
</div>
<!-- 圖表卡片 -->
<div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
<h3 class="font-semibold text-slate-600 mb-4">本週服藥率統計</h3>
<canvas id="complianceChart" class="w-full"></canvas>
</div>
</section>
</main>
<!-- 前端動態渲染腳本 -->
<script>
async function fetchDashboardData() {
try {
// 1. 抓取藥單紀錄
const resPrescription = await fetch('/prescriptions');
const resultPrescription = await resPrescription.json();
if (resultPrescription.status === 'success') {
renderPrescriptions(resultPrescription.data);
}
// 2. 抓取打卡紀錄
const resLogs = await fetch('/intake-logs');
const resultLogs = await resLogs.json();
if (resultLogs.status === 'success') {
renderIntakeLogs(resultLogs.data);
}
} catch (err) {
console.error("載入儀表板資料失敗:", err);
}
}
function renderPrescriptions(data) {
const container = document.getElementById('prescription-list');
if (!data || data.length === 0) {
container.innerHTML = '<div class="bg-white p-6 rounded-2xl text-slate-400">尚無歷史藥單紀錄</div>';
return;
}
container.innerHTML = data.map(item => `
<div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 space-y-4">
<div class="flex justify-between items-start">
<span class="text-xs text-indigo-600 font-bold bg-indigo-50 px-2.5 py-1 rounded-md">ID: ${item.id}</span>
<span class="text-xs text-slate-400">${item.created_at}</span>
</div>
${item.safety_warnings && item.safety_warnings.length > 0 ? `
<div class="bg-rose-50 border-l-4 border-rose-500 p-3 rounded-r-md">
<p class="text-xs font-bold text-rose-700 flex items-center gap-1">⚠️ 用藥安全警示:</p>
<p class="text-xs text-rose-600 mt-1">${item.safety_warnings.join(';')}</p>
</div>
` : ''}
<div>
<h4 class="text-sm font-semibold text-slate-500 mb-1">📢 白話文播報摘要:</h4>
<p class="text-slate-700 text-sm bg-slate-50 p-3 rounded-xl">${item.spoken_summary}</p>
</div>
<div>
<h4 class="text-sm font-semibold text-slate-500 mb-1">🎧 語音檔播放:</h4>
<audio controls class="w-full h-8">
<source src="${item.audio_url}" type="audio/mpeg">
您的瀏覽器不支援音訊播放。
</audio>
</div>
<div class="pt-2 border-t border-slate-100">
<h4 class="text-xs font-bold text-slate-400 mb-2">藥品明細 (${item.medicines.length} 種)</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
${item.medicines.map(m => `
<div class="bg-slate-50 p-2.5 rounded-lg text-xs space-y-1">
<p class="font-bold text-slate-800">${m.name} <span class="text-indigo-500">(${m.type})</span></p>
<p class="text-slate-500">${m.frequency} | 劑量:${m.dosage} ${m.timing}</p>
</div>
`).join('')}
</div>
</div>
</div>
`).join('');
}
function renderIntakeLogs(data) {
const container = document.getElementById('intake-log-list');
if (!data || data.length === 0) {
container.innerHTML = '<p class="text-slate-400">尚無打卡紀錄</p>';
return;
}
container.innerHTML = data.map(log => `
<div class="flex items-center justify-between p-2 rounded-lg bg-slate-50">
<span class="text-xs font-semibold text-emerald-600 flex items-center gap-1">
✅ 已完成服藥
</span>
<span class="text-xs text-slate-400">${log.taken_at}</span>
</div>
`).join('');
// 初始化 Chart.js 順從度圖表
renderChart(data.length);
}
function renderChart(takenCount) {
const ctx = document.getElementById('complianceChart').getContext('2d');
new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['按時服藥', '未紀錄/漏吃'],
datasets: [{
data: [takenCount, Math.max(0, 10 - takenCount)],
backgroundColor: ['#10b981', '#f1f5f9'],
borderWidth: 0
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' }
}
}
});
}
// 初始化載入資料
fetchDashboardData();
</script>
</body>
</html>
在 Docker 指令之前,提醒讀者需要先安裝 flask-limiter,以及重啟 Docker 容器時一定要先刪除舊容器(-f 參數),避免 Port 衝突或容器連線拒絕:
若本地開發環境尚未安裝流量限制套件,請先執行安裝:
pip install flask-limiter
更新 app.py 與新增 templates/dashboard.html 後,重新構建並啟動 Docker 容器(先刪除同名舊容器):
# 1. 刪除同名舊容器(避免名稱衝突導致新容器閃退或連線拒絕)
docker rm -f prescription_service
# 2. 重新構建 Docker 鏡像
docker build -t prescription-vlm:v1.0 .
# 3. 背景執行新容器並綁定 Port 5000
docker run -d -p 5000:5000 --env-file .env --name prescription_service prescription-vlm:v1.0
2.瀏覽器開啟 Web 儀表板
開啟瀏覽器造訪:http://127.0.0.1:5000/dashboard
.mp3 語音檔。
測試成功後,提交 Day 15 的程式碼:
git add .
git commit -m "保留雙引號 改填寫自己要記錄的標記 ex.鐵人賽第十五天"
git push
今天我們成功建立了第一個全功能的 Web 關懷儀表板,將多模態 VLM 解析成果、語音播報與 LINE 服藥打卡數據徹底整合視覺化,實現了「長輩聽得懂、家屬看的見」的完整系統體驗。
明天(Day 16),我們將開始進入 長者端無障礙介面與互動優化,探討如何針對銀髮族設計更大字體、更高對比以及語音互動反饋的對話介面體驗!